本节介绍了Go编译器执行的一些优化。
逃逸分析
内联
去掉无效代码
这些都是全部在编译器的前端处理,而代码仍以 AST 形式处理;然后代码传递给 SSA 编译器以进行进一步优化。
Go 编译器开始于 2007 年左右的 Plan9 编译器工具链的分叉。当时,编译器与 Aho and Ullman 的《Dragon Book》非常相似。
2015 年,当时的 Go 1.5 编译器是用 C 写的。
一年后,Go 1.7 引入了新的基于 SSA 技术的编译器后端,取代了以前的 Plan 9 样式代码生成。新的编译器后端为通用和体系结构特定的优化带来了许多机会。
我们讨论的第一个优化是逃逸分析。
为了说明逃逸分析的作用,我们回想起 Go 的规范没有提到堆或堆栈。它仅提及该语言在介绍中是被垃圾收集的,并且没有给出关于如何实现这一点的提示。
Go 规范的兼容 Go 实现可以存储堆上的每个分配。这将给垃圾收集器带来很大压力,但它绝不是不正确的 —— 数年来,gccgo 对逃逸分析的支持非常有限,因此可以有效地被视为在这种模式下运行。
但是,goroutine 的堆栈是存储局部变量的位置;没有必要在堆栈上进行垃圾回收。因此,如果这样做是安全的,则放置在堆栈上的分配将更有效。
在某些语言中,例如 C 和 C++,选择在堆栈上或堆上分配是程序员的手动操作的 —— 堆分配使用 malloc
和 free ,堆栈分配是通过 alloca 进行的。错误使用这些方法是内存 gg 的常见原因。
在 Go 中,如果值超过函数调用的生命周期,编译器会自动将值移动到堆。也就是说该值逃逸到了堆。
type Foo struct {
a, b, c, d int
}
func NewFoo() *Foo {
return &Foo{a: 3, b: 1, c: 4, d: 7}
}
在此示例中,在 NewFoo
中分配的 Foo
将移动到堆中,这样 NewFoo
在返回后它的返回值仍然保持有效。
这在 Go 的最开始的时候就一直存在。与其说是优化,不如说是自动纠正功能。在 Go 中不可能意外返回堆栈分配变量的地址。
但编译器也可以做相反的事情;它可以找到假定在堆上分配的东西,并将它们移动到堆栈中。
让我们看一个例子
func Sum() int {
const count = 100
numbers := make([]int, count)
for i := range numbers {
numbers[i] = i + 1
}
var sum int
for _, i := range numbers {
sum += i
}
return sum
}
func main() {
answer := Sum()
fmt.Println(answer)
}
Sum
将累加 1 到 100,并返回结果。
由于数字切片仅在 Sum
内部引用,因此编译器将安排将该切片的 100 个整数存储在堆栈上,而不是堆上。不需要垃圾收集 numbers
,当 Sum
返回时,它会自动释放。
要打印编译器的逃逸分析决定,请使用 -m 命令。
% go build -gcflags=-m examples/esc/sum.go
# command-line-arguments
examples/esc/sum.go:22:13: inlining call to fmt.Println
examples/esc/sum.go:8:17: Sum make([]int, count) does not escape
examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heap
examples/esc/sum.go:22:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
第 8 行显示编译器已正确推断 make([]int, 100) 的结果不会转义到堆。原因却没有
第 22 行 answer
逃逸到堆的原因为 fmt.Println,它是一个可变函数。可变函数的参数被添加进切片中,在这种情况下为 []interface{},因此将 answer
放入接口值中,因为它被调用 fmt.Println 引用。由于 Go 1.6 垃圾回收器要求通过接口传递的所有值都是指针,因此编译器看到的大致是:
var answer = Sum()
fmt.Println([]interface{&answer}...)
我们可以使用 -gcflags="-m -m" 命令来确认。返回
% go build -gcflags='-m -m' examples/esc/sum.go 2>&1 | grep sum.go:22
examples/esc/sum.go:22:13: inlining call to fmt.Println func(...interface {}) (int, error) { return fmt.Fprintln(io.Writer(os.Stdout), fmt.a...) }
examples/esc/sum.go:22:13: answer escapes to heap examples/esc/sum.go:22:13: from ~arg0 (assign-pair) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heap
examples/esc/sum.go:22:13: from io.Writer(os.Stdout) (passed to call[argument escapes]) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: main []interface {} literal does not escape
简而言之,不必担心第22行,这对本次讨论并不重要。
此优化是否对所有 count 值都成立?
如果 count
是变量而不是常量,此优化是否成立?
如果 count
是 Sum 的参数,此优化是否成立?
此示例是故意这样的。它不是真正的代码,只是一个例子。
type Point struct{
X, Y int
}
const Width = 640
const Height = 480
func Center(p *Point) {
p.X = Width / 2
p.Y = Height / 2
}
func NewPoint() {
p := new(Point)
Center(p)
fmt.Println(p.X, p.Y)
}
NewPoint
将创建新的 *Point 值,p
. 我们将 p
传递给Center
函数,该函数将点移动到屏幕中心的位置。最后,我们打印 p.X 和 p.Y 的值。
% go build -gcflags=-m examples/esc/center.go
# command-line-arguments
examples/esc/center.go:11:6: can inline Center
examples/esc/center.go:18:8: inlining call to Center
examples/esc/center.go:19:13: inlining call to fmt.Println
examples/esc/center.go:11:13: Center p does not escape
examples/esc/center.go:19:15: p.X escapes to heap
examples/esc/center.go:19:20: p.Y escapes to heap
examples/esc/center.go:19:13: io.Writer(os.Stdout) escapes to heap
examples/esc/center.go:17:10: NewPoint new(Point) does not escape
examples/esc/center.go:19:13: NewPoint []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
即使 p 是使用新函数分配的,也不会将其存储在堆上,因为p的引用没有逃逸到 Center
函数。
问题:第19行,如果 p 不逃逸,那什么是逃逸到堆?
在 Go 函数调用中具有固定开销;堆栈和抢占检查。
这在一定程度上可以通过硬件分支预测器得到改善,但在函数大小和时钟周期方面仍然存在开销。
内联是避免这些成本的经典优化。
直到 Go 1.11 内联仅适用于 _leaf _函数,该函数不调用另一个函数。这样做的理由是:
如果函数执行大量工作,则前导开销可以忽略不计。这就是为什么函数超过一定大小(目前一些指令计数,加上一些操作,防止所有内联(例如,在 Go 1.7 之前的 switch)
另一方面,小型函数会为执行的相对较小的有用的工作进行固定的开销。这些是内联目标的功能,因为它们受益最大。
另一个原因是沉重的内联使堆栈跟踪更难跟踪。
func Max(a, b int) int {
if a > b { return a } return b
}
func F() {
const a, b = 100, 20
if Max(a, b) == b {
panic(b)
}
}
同样,我们使用 -gcflags=-m 命令来查看编译器的优化决策。
% go build -gcflags=-m examples/inl/max.go
# command-line-arguments examples/inl/max.go:4:6: can inline Max
examples/inl/max.go:11:6: can inline F
examples/inl/max.go:13:8: inlining call to Max
examples/inl/max.go:20:6: can inline main
examples/inl/max.go:21:3: inlining call to F
examples/inl/max.go:21:3: inlining call to Max
编译器打印了两行。
第一行在第3行,Max的声明,告诉我们它可以内联。
第二个是 Max
函数已在第 12 行内联到调用函数中。
在不使用 //go:noinline 注释的情况下,重写 Max
,使其仍返回正确的答案,但编译器不再认为它可内联。
编译 max.go 并查看优化版本的 F() 变成什么样了。
% go build -gcflags=-S examples/inl/max.go 2>&1 | grep -A5 '"".F STEXT'
"".F STEXT nosplit size=2 args=0x0 locals=0x0
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11) TEXT "".F(SB), NOSPLIT|ABIInternal, $0-0
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11) FUNCDATA $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:13) PCDATA $2, $0
一旦 Max
被内联到 F中,它就是 F 的主体,这个函数中什么也没有发生。我知道屏幕上有很多文字,但请相信我,唯一发生的事情是 RET。实际上 F 变成了:
func F() {
return
}
为什么我在 F()中将 a 和 b 声明为常量?
实验 a 和 b 作为变量声明的输出会发生什么情况?如果 a 和 b 作为参数传入 F(),会发生什么情况?
-gcflags=-S 不会阻止工作目录中构建最终的二进制文件。如果发现随后的 go build …运行没有输出,请删除工作目录中的 ./max 二进制文件。
使用 -gcflags=-l 标志执行调整内联级别。传递一个 -l 将禁用内联,两个或更多将在更激进设置下启用内联。
-gcflags=-l
,内联已禁用。
没有参数的话,定期内联。
-gcflags='-l -l'
内联级别2,更具侵略性,可能更快,可能会使更大的二进制文件。
-gcflags='-l -l -l'
内联级别3,再次更具攻击性,二进制文件肯定更大,也许再次更快,但也可能是错误。
-gcflags=-l=4
(four -l
s) 在 Go 1.11 将启用实验中间堆栈内联优化。
自Go1.12以来,所谓的中间堆栈内联已经启用(它以前在 Go 1.11 的预览版中可用,带有 -gcflags='-l -l -l -l' 标志)。
我们可以在前面的例子中看到一个中间栈内联的例子。在 Go 1.11 和更早的版本中,F 不应该是一个 leaf 函数-它调用了 max。但是由于内联改进,F现在内联到它的调用方中。这有两个原因。当 max被内嵌到 F 中时,F 不包含其他函数调用,因此它成为一个潜在的 leaf 函数,假设其复杂度预算没有被超过。因为 F 是一个简单的函数,所以无效代码消除消除了很多复杂的预算。
中间堆栈内联可用于内联函数的快速路径,从而消除了快速路径中的函数调用开销。最近在 Go 1.13 中引入的 CL 展示了将此技术应用于 sync.RWMutex.Unlock()。
进一步阅读
为什么 a 和 b 是常量很重要?
为了了解发生了什么,让我们来看看编译器在将 Max
内联到 F 时看到什么。我们不容易从编译器中获取,但可以直接手动完成。
Before:
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func F() {
const a, b = 100, 20
if Max(a, b) == b {
panic(b)
}
}
After:
func F() {
const a, b = 100, 20
var result int
if a > b {
result = a
} else {
result = b
}
if result == b {
panic(b)
}
}
因为 a 和b 是常数,编译器可以在编译时证明分支永远不会是 false;100
总是大于 20。因此编译器可以进一步优化 F 到
func F() {
const a, b = 100, 20
var result int
if true {
result = a
} else {
result = b
}
if result == b {
panic(b)
}
}
现在,分支的结果是知道的,然后 result
的内容也已知。这是调用分支消除。
func F() {
const a, b = 100, 20
const result = a
if result == b {
panic(b)
}
}
现在,分支被消除,我们知道 result
总是等于 a,并且因为 a 是常量,我们知道 result
是常量。编译器将此证明应用于第二个分支
func F() {
const a, b = 100, 20
const result = a
if false {
panic(b)
}
}
再次使用分支消除,将 F 的最终形式简化为
func F() {
const a, b = 100, 20
const result = a
}
最后就是
func F() { }
分支消除是称为无效代码消除的优化类别之一。实际上,使用静态证明来显示一段代码永远无法到达,俗称 "dead",因此,无需在最终二进制文件中对其进行编译,优化或发布。
我们看到了无效代码消除如何与内联协同工作,以减少通过删除被证明无法访问的循环和分支生成的代码量。
你可以利用这一点来实现开销很大的调试,并将其隐藏在背后
const debug = false
与构建标签结合使用,这可能非常有用。
提供了编译器命令:
go build -gcflags=$FLAGS
调查以下编译器函数的操作:
-S 打印正在编译的包装的(Go flavoured)组件。
-l 控制内衬人的行为;-l 禁用内联,-l -l 增加它(更多 -l 增加了编译器对内联代码的胃口)。试验编译时间、程序大小和运行时间的差异。
-m 控制优化决策的打印,如内联、逃逸分析。-m
-m` 打印了有关编译器想法的更多详细信息。
-l -N 禁用所有优化。
如果发现随后的 go build … 运行没有输出,请删除工作目录中的 ./max 二进制文件。
Go 是一种有边界检查语言。这意味着将检查数组和切片下标操作,以确保它们在各自类型的范围内。
对于数组,可以在编译时完成此操作。对于切片,必须在运行时完成此操作。
var v = make([]int, 9)
var A, B, C, D, E, F, G, H, I int
func BenchmarkBoundsCheckInOrder(b *testing.B) {
for n := 0; n < b.N; n++ {
A = v[0]
B = v[1]
C = v[2]
D = v[3]
E = v[4]
F = v[5]
G = v[6]
H = v[7]
I = v[8]
}
}
使用 -gcflags=-S反汇编 BenchmarkBoundsCheckInOrder。每个循环执行多少个边界检查操作?
func BenchmarkBoundsCheckOutOfOrder(b *testing.B) {
for n := 0; n < b.N; n++ {
I = v[8]
A = v[0]
B = v[1]
C = v[2]
D = v[3]
E = v[4]
F = v[5]
G = v[6]
H = v[7]
}
}
重新排列我们分配 A 到 I 的顺序会影响程序。
拆开 BenchmarkBoundsCheckOutOfOrder
并找出答案。
重新排列下标操作的顺序是否会影响函数的大小?它会影响功能的速度吗?
如果将 v 移入Benchmark
函数内会怎样?
如果将 v 声明为数组 var v [9]int,会发生什么?